release: dev → prod 2026-09-05 (finance train: money invariants, B2C tax invoices, ticker, Razorpay productionization, wallet/overage/payout fixes) - #1491
Conversation
…ation is a row (#1378) ## What Wave-6 PR C (umbrella #1319; audit trail from #1333). Two gaps in `BookingStatusHistory`, one found in production on 2026-09-03. - **Every history row now names its appointment.** The seven CAS helpers in `lib/booking/transitions.ts` already read the row's pre-image inside the transaction; that read now also carries the appointment id, so `appointmentId` is stamped on the audit row without any caller changing. Consultation, Webinar, TrialSession and RescheduleRequest resolve it directly. Subscription and Class own `Appointment[]`, so they take the id only when exactly one live appointment exists (`take: 2` is the whole question) and otherwise leave it null with the reason in the helper's doc comment. `transitionSlotCompletion` takes it from each moved row. Helper signatures are unchanged. - **Creation is a row.** On production, `BookingStatusHistory` had zero rows for a freshly created request because creation is not a transition, so the staff timeline read "nothing has moved on this booking yet" for every new booking. A small `appendCreationHistory` helper writes one opening row with the literal from-status `"CREATED"` (not the `"UNKNOWN"` sentinel, which on this surface means "a concurrent writer moved the row between the pre-read and the update"). It runs in the same transaction as the create at the three creation paths: `handleConsultationCheckout`, `handleSubscriptionCheckout`, and `app/api/slots/request-for-approval/route.ts`, whose nested create is now wrapped in a short transaction so the audit row is atomic with it. Webhook fallback creators are deliberately untouched. - Docs that asserted the column was never populated (ADR A12, `docs/booking/README.md`, the ER label in `06-dependency-graphs.md`, doctrine skill rule 1, the read model's header) are corrected in place. ## Verification `prisma generate` + cold `tsc` clean; `__tests__/booking-algorithm` + `__tests__/payments` + `__tests__/maintenance` + `__tests__/booking`: 129 suites / 1,720 tests green; eslint zero; prettier clean. One pin file, `__tests__/booking/status-history.test.ts`, with two cases: a consultation with an appointment gets `appointmentId` on its history row without the caller passing it, and the `"CREATED"` opening row. Two existing mocks updated minimally. Commit 1 is format-only (the request route was prettier-dirty on `dev`); commit 2 carries the change. ## Restack notes Adds the `## Changelog: 2026-09-03 — wave 6` heading; #1376 adds the same heading, so the second to land takes a one-line conflict. `checkout.ts` hunks are confined to the two handler bodies plus an import block near the top, clear of #1376's import change. ## Out of scope, found on the way `expirePaymentPendingRequests` in `scripts/appointments/expire-stale-requests.ts` still flips `APPROVED_PENDING_PAYMENT → EXPIRED` with a bare `updateMany`, so it writes no history row; handed to wave-6 PR B, which is already editing that file. Part of #1319. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…t the sweep re-attempt unplaced sessions (#1379) ## What Wave-6 PR A (umbrella #1319; closes the #1206 follow-up). A partial allocation confirmed N of M sessions and nothing ever re-attempted the rest, because `autoAllocate` could only re-plan from scratch, deleting every confirmed row on the way. The allocator now has a top-up mode and the hourly reconciliation sweep uses it. - **`autoAllocate` takes `topUp`.** For a recurring event with at least one confirmed session, no tentative rows and fewer confirmed sessions than the plan requires, every existing confirmed appointment is treated as fixed: `deleteExistingAppointments` is never called, the fixed sessions stay in the occupancy set and seed the weekly and per-day caps, and only `plan − confirmed` sessions are searched for. A complete event returns `noChange: true` instead of throwing "already fully allocated". Shortfall is `plan − all confirmed`, not `plan − future confirmed`, so a delivered session is never owed twice. Every non-top-up call is byte-for-byte today's behaviour. - **Nothing is notified on a no-change run.** The suppressor in `allocate()` sends the existing partial or complete notice only when a session was placed, so the sweep cannot page a consultee hourly. - **The sweep re-attempts.** `reconcile-slot-availability` collects APPROVED subscriptions and SCHEDULED or IN_PROGRESS classes whose window is still open, that have confirmed sessions below the plan and zero tentative rows, and attempts a top-up only when the consultant's availability rows changed after the event's last update. Bounded: 200 candidates read, 25 attempts, a 60-second wall-clock budget, one try/catch per event, counts surfaced on the job result. No schema column: a successful top-up re-stamps the request through its transition helper, so `updatedAt` already means "last attempted". - **The four allocate routes accept `topUp`**, gated by the same `canOverride` as the other consultant-only flags, and echo `noChange` on success. No UI change: there is no "allocate remaining" control today, and the shortage dialog re-plans deliberately. ## Verification Cold `tsc` clean (it caught one real bug on the way: `ClassPlan.consultantProfileId` is nullable); `__tests__/booking-algorithm` + `__tests__/payments` + `__tests__/maintenance` + `__tests__/booking` + `__tests__/collaborators`: 130 suites / 1,726 tests green; eslint 3 warnings, all pre-existing on `dev`; prettier clean. One pin file, `__tests__/booking-algorithm/allocation-top-up.test.ts`, whose transaction mock has no delete members: two of four confirmed places exactly two on the weeks the cap leaves open; a complete event returns `noChange` without entering the transaction or notifying; the same fixture without the flag reaches the delete and fails. Not verified: the sweep under real availability changes against a running app. ## Out of scope, found on the way - `expirePaymentPendingRequests` bypasses the CAS helper and the money predicate (handed to wave-6 PR B). - `reconcile-slot-availability` declares 8 of the 10 pool-budget minutes; the top-up pass stays inside it, but the job has no headroom for another tail. - Availability rows carry a `deletedAt` that nothing reads or writes; deletes are still hard, so "last published" is publish-only. Part of #1319. Closes the #1206 follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…last bare slot-status writes through the CAS helpers (#1380) ## What Wave-6 PR B (umbrella #1319). Doctrine rule 2 says a slot is freed by status alone, yet four sites outside the allocator still hard-deleted tentative `SlotOfAppointment` rows, and three status writers still bypassed the CAS helpers. - **The last tentative-hold deletes become soft cancels.** `expire-stale-requests.ts` (the PENDING-consultation expiry and `releaseStaleRescheduledSlots`), `cleanup-tentative-slots.ts`, the direct-booking arm of `cancel-pending.ts`, and the payment-failed arm in `webhooks/handlers.ts` now call `transitionSlotCompletion` to CANCELLED with `deletedAt`, carrying every original guard (`isTentative`, the no-successful-payment predicate, the re-checked parent status) verbatim into the CAS WHERE. Follow-up reads that counted "remaining slots" now filter live rows, or the EXPIRED transition they gate would never fire again. Occupancy already ignored tombstones and is untouched. Repo-wide slot deletes: 11 → 6; production code: 9 → 4, all four the allocator's sanctioned never-paid release. - **The from-set is the default CANCELLED set, deliberately.** `auto-complete-appointments` flipped any SCHEDULED slot to UNVERIFIED an hour past `endsAt` with no `isTentative` filter, so a hold on a past-dated slot was routinely UNVERIFIED before the sweeps saw it; a from-set narrowed to SCHEDULED/RESCHEDULED would strand exactly the holds these sweeps exist to free. Note for future callers: the helper overwrites `completionStatus` in the caller's WHERE with its from-set, so a status scope must be passed as `fromIn`. - **Three more bare status writes go through the helpers.** `expirePaymentPendingRequests` moves APPROVED_PENDING_PAYMENT → EXPIRED through `transitionConsultationRequest` / `transitionSubscriptionRequest` with the money predicate repeated in the WHERE, one transaction per request, a raced capture counted as skipped; it also gains a 500-per-run cap with a warning. `completeIndividualSlots` in `auto-complete-appointments` (the last slot-completion writer outside the helper, and the source of the UNVERIFIED interaction above) now uses `transitionSlotCompletion` with `isTentative: false`, `deletedAt: null` and `fromIn: [SCHEDULED]`, so an unpaid hold is never marked UNVERIFIED and a tombstone is never touched. The maintenance preflight's upcoming-slot count filters `deletedAt: null`. - **The forbidden-delete pin is global.** `slotOfAppointment.delete(Many)` joins the forbidden list and a new case walks `scripts/ jobs/ lib/ app/ utils/` against an allowlist of exactly the allocator, `prisma/` and `scripts/db/`. - The cancel-vs-webhook chaos scenario asserted `tentativeLeft === 0` after a winning cancel, true only while the cancel deleted; it now counts live holds and its arm/restore steps clear and replay `deletedAt`. Source-level fix; the race workflow fires on push to `dev`. ## Verification Cold `tsc` clean; `__tests__/booking-algorithm` + `__tests__/payments` + `__tests__/maintenance` + `__tests__/booking`: 128 suites / 1,720 tests green; eslint zero on every touched line (12 pre-existing warnings in untouched files); prettier clean. Commit 1 is format-only for the five files that were prettier-dirty on `dev`. Not verified: the sweeps against a running app; the chaos scenario was not executed. ## Restack notes Appends to the `## Changelog: 2026-09-03 — wave 6` section; expect a one-line heading conflict with whichever wave-6 PR lands first. Part of #1319. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
… defaults off (#1382) ## What Wave-6 PR D (umbrella #1319; the rate-card decision recorded on #1338). `resolveEffectiveRateCard` resolves membership override → contract-scoped → plan-scoped → org → default, but its only settlement caller, `resolveOrgSplit`, passed just `{ orgId, membershipOverrideId, at }`, so contract- and plan-scoped cards could be created and were never selected. Owner decision 2026-09-03: forward the scope behind a flag that defaults off, with a settlement parity test. - **Flag.** `RATE_CARD_SCOPED_RESOLUTION`, on only when the value is exactly `on`, read per call through `isScopedRateCardResolutionEnabled()` in `lib/api/organizations/rate-card.ts`. Off, the resolver call is the pre-change call verbatim and the scoped tiers are never queried. - **Forwarding.** `resolveOrgSplit` takes the booking (`paymentId`, `appointmentType`) and, with the flag on, forwards `planType` (an exhaustive `Record<AppointmentType, CoveredPlanType>`, so an enum rename fails to compile), `planId` where the booking carries one (webinar and class), and `contractId` resolved at settlement time through `BookingUtilization → ProgramAssignment → Program → Contract`, which exists for org-funded bookings because checkout writes the utilization row in the same transaction. `at` stays `payment.createdAt`. The collaborator leg stays unscoped (ADR 18 org-blindness). - **Tenancy guard.** The contract is forwarded only when `Contract.organizationId` equals the host org being resolved. The contract belongs to the sponsoring org while `resolveOrgSplit` resolves the expert's host org, and the resolver matches `ownerContractId` without re-asserting the org, so an unguarded forward could settle one tenant's booking on another tenant's negotiated split. The guard can only select fewer cards, never a wrong-tenant one; contract scope is therefore reachable only where sponsor and host coincide. - **Both surfaces see the flag.** The earnings healer workflow (`sync-payment-earnings.yml`) carries the variable from repository `vars`, because it accrues through the same `createEarningsFromPayment` as the capture webhook; set on Netlify alone, one booking would settle two different ways depending on which surface caught it. Declared in the required-secrets reference and the feature-flags doc; the #1335 narrowing in the booking-to-earnings doc is rewritten to describe the flag. ## Verification Cold `tsc` clean; `__tests__/booking-algorithm` + `payments` + `maintenance` + `booking` + `enterprise` + `collaborators`: 221 suites / 2,434 tests green; eslint zero on every touched line; prettier clean; hygiene guard ok. One pin file, `__tests__/payments/rate-card-scoped-settlement.test.ts`, which drives the real chain from `createEarningsFromPayment` to the bps on the earnings rows: flag on selects the plan-scoped card, flag off selects the org default and issues no `planId` query, and a sponsoring contract owned by another org is never queried. Three existing suites that partially mock the rate-card module gained one line each. Commit 1 is format-only for six files that were prettier-dirty on `dev`. No live settlement was run. ## Deliberately left out A card scoped to a specific consultation or subscription plan is still never selected: `planType` reaches those tiers for all four kinds, but only webinar and class carry a plan id into settlement. Closing it means widening the payment projection at the three `createEarningsFromPayment` call sites; recorded in the docs table. Part of #1319. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…n, not the pool it is blocking (#1435) * fix(payments): the checkout consent gate reads through the transaction, not the pool it is blocking Netlify runs this app with a pg pool of PG_POOL_MAX=1 and a 3 s connect timeout. An interactive Prisma transaction checks out that single connection and holds it until it commits, so any query sent to the global client while a transaction is open queues for a connection that only the blocked transaction can release. The request cannot make progress and pg eventually gives up with "timeout exceeded when trying to connect". That is what killed POST /api/checkout on the deploy preview: the DPDP SESSION_BOOKING gate in validateSlotAvailability called checkConsent, which read on the global client, and validateSlotAvailability is called from inside three separate transactions on the plain Razorpay consultation path (calculateAmountAndValidate, revalidateInsideLock and the Serializable booking transaction). The first of them deadlocked against itself and the route answered 500 with the pg message, before any row was written. The org-sponsored branch of revalidateInsideLock had the same defect on its own consent check. Neither is new to the finance train; both have been on dev since the LCY-2 consent cascade landed, and production runs the same single-connection pool, so consultation checkout could not take a payment there either. checkConsent now takes an optional client that defaults to the global one, following the getUserCredits convention, and both in-transaction call sites pass tx. The gate itself is unchanged and still fails closed. The redundant dynamic imports in validateSlotAvailability are gone; both symbols were already static imports at the top of the file. The pin models the pool rather than the query: a global-client call raised while a transaction is open throws the same pg error the preview logged, so the test fails against the unfixed code with "timeout exceeded when trying to connect" and passes once the gate reads through tx. A second case asserts the withdrawn-consent block still rejects, since a fix that silently disabled the gate would also make the first case pass. lib/compliance/dpdp.ts was already failing prettier --check on dev; formatting the file to commit this change also settles that one unrelated line. Part of #1421 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): org-programme notifications look up their assignment after the checkout transaction commits Three Novu bells on the org-programme path issued their ProgramAssignment lookup on the global Prisma client from inside the Serializable checkout transaction. They were fire-and-forget, so they did not block the booking, but under PG_POOL_MAX=1 that query queues behind the transaction's own connection and dies at the 3 s pg connect timeout — and the .catch swallowed it, so on Netlify the bell was simply lost. The comments claimed the outer client was chosen to read committed state; the pool makes that impossible from where the call sat. All three now capture what they need inside the transaction and ring after it has settled. The 80% cap-near warning and the member-due overage charge travel out on the transaction's return value, so they ring only for the attempt that actually committed, and a P2034 retry can no longer ring them twice — which is what the capNearNotified flag existed to prevent, so it is gone. The cap-exhausted bell is the one whose news IS the refusal, so it rides out on a holder and rings from the retry wrapper's catch, preserving today's behaviour of telling the org even though the booking rolled back. recordOverageAtCheckout returns the pending member-due notification instead of ringing it, and the ringing moves to notifyOverageDueAfterCommit in the same module, so the Novu graph stays where its docblock says it lives. The two bells share one lookup and one roster, so the duplicated query and error handling collapse into dispatchProgramBell. approval-path-correctness asserts on checkout source text within a fixed character window of the STEP 5 marker, which a comment above the call can push the call out of. It now strips comments and looks in a wider window, so it pins the code rather than the prose. Part of #1435 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…nment, bugs/ annotations (#1392) * docs(finance): the 2026-09-03 verdict record, ADR 26 (GST principal model), compliance-doc alignment, and the bugs/ notes annotated Closes #1373 Part of #1319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * docs(payments): the money subsystem in four high-level diagrams A newcomer's map of where money truth is written, what may lag behind it, and which sweep closes each gap, for both the B2C and the B2B paths. Part of #1373 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * docs(finance): review-comment triage round 1 on the verdict record and ADR 26 Aligns "Known gaps" sections in bugs/*.md with the 2026-09-03 verdict tables that already sit above them in the same files (several rows still called FIXED-BY/STALE/OVERSTATED items open). Resolves two Locked-vs- Recommendation contradictions by labeling the superseded recommendation historical. Corrects the RBI architecture-memo status self-contradiction, the stale SCIM-501 checklist/audit-proposal text against the now-shipped lib/scim/, the inbox/outbox terminology and the five-minute hard-bound claim in the payments HLD, and clarifies the allocationIdempotencyKey schema comment to describe the known concurrent-replay 409 gap. Adds ADR 26's missing Alternatives-considered section and corrects its B2C tax-invoice claim to in-flight (no ConsumerInvoice/ConsumerCreditNote model exists yet). Leaves the TCS/GSTR-8 conditionality, SAC-code mapping, RazorpayX FFMC/APSO licensing claim, and TDS-form-renumbering comments as needs-decision — those touch tax/regulatory statements the verdicts were deliberately locked against, not doc-consistency bugs. Part of #1373 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * docs(compliance): TCS is CA-gated, the RazorpayX licensing claim is sourced, and the TDS forms carry their 2026 numbers Part of #1373 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…weeps (#1390) * chore(cron): a Netlify scheduled ticker drives the sub-hourly money sweeps, and the fleet stops promising cadences Actions cannot keep Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(env): document CRON_SECRET and CRON_TICK_BASE_URL for the ticker The E2E pass found CRON_SECRET absent from every local env, so the cleanup routes were unreachable; the sample now names both ticker vars. Part of #866 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(cron): the ticker strips trailing slashes without a backtracking regex Part of #866 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(cron): report ticker failures honestly, stop masking earnings-route errors Round-1 triage of PR #1390 review comments. The scheduled ticker returned 200 even when a target failed, and sync-payment-earnings/release-earnings pinned status:()=>200 over their own success:false results, so both self-reported healthy on error. Also corrected cron-setup.md's local-invocation instructions, which claimed netlify dev fires a scheduled function on its real cadence and that a scheduled function can be curl'd directly by URL — neither is true per Netlify's docs; netlify functions:invoke is the only supported local trigger. Left unchanged as needs-decision (money-semantics, not this triage's call): sharing the ticker's ?limit budget across paired workers in abandoned-payments and reconcile-pending-refunds, rejecting invalid `limit` as 400 instead of unbounded, and adding deterministic ordering to bounded cleanup-abandoned-payments queries. Part of #866, #1010 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(cron): each sweep pass owns its limit, bad limits are refused, bounded queries drain oldest-first Part of #866 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(cron): expiry and slot-reconcile sweeps transition through the CAS helpers and refund exactly the rows they expired Both subscription expiry arms in scripts/appointments/expire-stale-requests.ts wrote status with a bare updateMany. The PENDING arm re-ran the 30-day predicate in the write instead of naming the ids it had read, so a subscription that crossed the cutoff between the two statements was expired by the write, excluded from refundPaymentsForExpired (which was handed the READ set), and never revisited because the next run reads PENDING only: paid, expired, silently unrefunded. The APPROVED-unallocated arm had the mirror defect, refunding every read id even when the write matched fewer. Neither wrote a bookingStatusHistory row. Each row now expires through transitionSubscriptionRequest in its own transaction with the cohort's own predicate repeated in the CAS WHERE, a from-set that is a deliberate subset of REQUEST_ALLOWED_FROM.EXPIRED, and a recorded actor and reason; only the ids the helper actually transitioned are refunded. Both arms take the file's existing per-run cap and oldest-first drain now that they are per-row. The tentative-clear sweep in scripts/appointments/reconcile-slot-availability.ts scoped its write by id alone. A partial reschedule releases a slot as isTentative=true / completionStatus=RESCHEDULED while leaving the parent APPROVED, which the sweep's parent-status guard does not see, so such a slot landing between the read and the write was stamped confirmed and blocked the calendar for a session nobody would deliver. The write now repeats the cohort predicate (ADR 21) and logs the shortfall when rows leave the cohort mid-run. Closes #1423 Closes #1424 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ng what Payment.amount has always meant (#1385) * fix(payments): the leg-sum identity excludes referral credits, matching what Payment.amount has always meant Two definitions of `Payment.amount` were both being enforced and could not both be true. The schema has always described it as the final amount charged to the gateway — after discounts and tax, and after referral credits are deducted — and `handleCheckout` writes a CARD leg equal to exactly that figure. `lib/referrals/service.ts` then writes a positive REFERRAL_CREDIT leg for the credit it just applied, so the legs on a credit-funded booking added up to `amount` plus the credit. Every reader of the invariant, though, took it as a plain sum over all non-reversal legs: `checkPaymentLegsSumToAmount`, the checkout sweep, the nightly reconciler, and the `payment_legs_sum_to_amount` constraint trigger. That trigger is DEFERRABLE INITIALLY DEFERRED and is live on the database, so it fired at COMMIT and rolled back the entire checkout transaction for any booking that spent referral credit. Either the field meant the pre-credit price — in which case the gateway was being asked for the wrong number — or the credit leg did not belong in the sum. This keeps the field's long-standing meaning and narrows the identity instead: the funding sum is now Sigma(non-reversal, non-REFERRAL_CREDIT legs) === Payment.amount, in the checker, in the trigger, and in the docs. The credit leg is untouched and still posts as the PLATFORM_PROMO debit; the DISCOUNT plug in earnings-service.ts already based itself on the sum of funding-leg debits including PLATFORM_PROMO, so the journal side needed no change at all. Also closes the inverse of #1357 7.4 in `rollupOrgInvoiceAccruals`: the allowed-from guard IS the filter, so an OverageEvent that is no longer PENDING silently did not move to ACCRUED and the discarded count was the only evidence. Its marginal is already inside the invoice's line amounts, so the next rollup bills it again. The count is now captured and a zero records a system error naming the event and the invoice. The invoice still commits — refusing to issue would strand the whole cycle. NOTE FOR DEPLOY: `prisma db push` does not manage triggers. The live database still carries the old predicate until `npm run db:leg-triggers` is run, and credit-funded checkouts keep failing at COMMIT until then. The script is idempotent. Closes #1347 Closes #1357 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the leg-sum trigger skips the LICENSE zero-leg case like the checker does `checkPaymentLegsSumToAmount` carves out the payment whose only non-reversal legs are zero-value LICENSE legs: a licensed seat is absorbed at contract time, so the leg is deliberately 0 while `Payment.amount` stays at the full list price, and the sum comparison is structurally false for every one of them. The constraint trigger never learned that carve, even though its header claims to mirror the checker exactly. It summed to 0, compared against a full-price `amount` and raised `check_violation` at COMMIT — rejecting precisely the checkout the application-side checker waves through. Same shape as #1347: a live DB constraint that is stricter than the invariant it claims to enforce, so a legitimate booking cannot commit. `assert_payment_legs_ok` now counts the non-reversal legs and how many of them are something other than a zero-value LICENSE leg, and skips the sum comparison when the second count is zero and the first is not. Both counts deliberately span REFERRAL_CREDIT so a credit sitting beside a licence leg keeps the payment in the comparison, exactly as the checker does. The reversal-pair loop still runs in the carve case. No object renamed, `-- SPLIT` cadence unchanged. Verified against a throwaway local Postgres 16 cluster: the file applies cleanly and 15 leg shapes behave as intended, including the licence-only carve, a licence leg beside real drift, and a credit beside a licence leg. The same 14 shapes run through `checkPaymentLegsSumToAmount` agree with the trigger on every case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): quote the camelCase leg columns so the constraint trigger runs at all `assert_payment_legs_on_leg_write` referenced `NEW.paymentId` / `OLD.paymentId` unquoted. PL/pgSQL case-folds a bare identifier, so it looked for `paymentid` on a Prisma-generated camelCase table and raised record "new" has no field "paymentid" on EVERY PaymentLeg insert, update and delete. Not just the drifted ones — the funding sum was never reached, so the trigger has guarded nothing since it was written in #1232 and the re-parenting branch added in #1233 inherited the same mistake. Verified by applying both the current file and the base `dev` revision to a throwaway local Postgres: a single CARD leg exactly matching `Payment.amount` still failed to commit. This matters now because #1347 ships with an instruction to re-run `npm run db:leg-triggers`. Installing the function as written would have converted a silent no-op into a hard failure on every checkout that writes a leg, so the quoting has to be right before that command is run. Quoting the four references restores the intended behaviour. Postgres short-circuits the `AND`, so the `OLD` reference in the re-parenting branch is never evaluated on an INSERT. Verified locally across all four paths: insert, same-payment update, a `Payment.amount` update, a leg delete that leaves the payment under-funded, and a cascade delete of the parent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): review round 1 — reversal-pair regression, system event after commit, honest orphan message, checker mirrors the trigger Part of #1347 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the leg checker rejects a zero reversal like the trigger, and the invoice rollup retries a serialization abort before reporting it Part of #1347 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… credit note on refund, and a monthly outward-supplies register for the CA (#1393) * feat(payments): every consumer supply gets a statutory tax invoice, a credit note on refund, and a monthly outward-supplies register for the CA The platform bills as principal supplier for GST (ADR 26): checkout charges 18% and settlement credits GST_PAYABLE. Organizations received a Rule 46 document for that; personal buyers received nothing at all, and there was no register the platform's own outward supplies could be filed from. This adds the document trail and only the document trail. It posts nothing to the ledger, derives no tax from a rate (the heads are split out of the tax the buyer was actually charged, so the invoice agrees with GST_PAYABLE to the paise), and builds no IRN. Closes #1365 Closes #1370 Closes #1361 Closes #1358 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(payments): the B2C invoice PR clears the Sonar gate — shared PDF serving, hardened workflow, smells The two consumer PDF routes were near-identical: everything except which row they load and which renderer they call is one contract, and it is the part that must not drift, so it moves into lib/pdf/serve-consumer-pdf.ts. The IST previous-calendar-month arithmetic the register export copied from the GSTR-8 draft moves into lib/compliance/ist-period.ts, used by both. The register workflow now installs without lifecycle scripts and runs the pinned local binaries rather than resolving packages at execution time, matching tds-return-draft.yml. The rest are smells on new lines: optional chaining, nested ternaries replaced by named resolvers, an explicit boolean comparison, Readonly props, the two table cells hoisted out of their parent components, and an explicit JSX separator where the newline was being stripped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(payments): one billing-state hook, one document frame, one best-effort minter — the B2C invoice PR under the duplication gate Four copies of the same twenty lines is what the density gate was measuring, and each copy was also a place the next change could be forgotten. - `useBillingState` owns the value, the profile pre-fill and its latch, and the POST body field, so each checkout page adds a hook call, a two-prop mount and three spreads instead of a block. - `StatutoryDocumentFrame` is the page furniture both consumer documents share: supplier block, recipient block, number and date, the supply table, the tax totals and the footer. The Devanagari registration moves there too, so the process registers the face once. The org documents are untouched and `orgStyles` is byte-identical — they carry an IRN block, a status badge and a due date that have no consumer counterpart. - `lib/data/payments-select.ts` holds the select shapes the admin and buyer payment routes share. These selects are the privacy boundary — `Dispute` carries internalNotes and evidence — so a copy is how a new column leaks. - `mintConsumerInvoiceBestEffort` is the identical try/catch both confirmation paths wrapped the mint in. Still never rethrows, still a Sentry warning; the gateway-intent lookup moved inside the guard, where it belonged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(env): document PLATFORM_GSTIN and PLATFORM_INVOICE_PREFIX in .env.sample The E2E pass found both absent locally, so the consumer-invoice mint no-ops silently; the sample now names them next to SUPPLIER_STATE_CODE. Part of #1365 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(pdf): the statutory frame drops a nested ternary and a void expression Part of #1365 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(compliance): review round 1 — real calendar dates, an honest period label, the credit-note rate, and an HTTP twin for the register CodeRabbit round 1 on the B2C statutory-invoice PR. Eight of the thirteen comments were actionable and are fixed here; four touch a tax figure, the place-of-supply default or which rows the register exports and are reported as needs-decision rather than changed unilaterally. - parseIsoDateOverride round-trips the parsed instant, because the ISO parser rolls "2026-02-30" over to 2026-03-02 and the NaN check accepted it, so an operator override silently exported a period nobody asked for. - The register's period label named periodEnd, which the query excludes (lt), so a CA read the file as covering one extra day. It now names the last day actually covered — stepping back a whole day, not a millisecond, because the label renders in IST and periodEnd - 1ms still lands in the next IST day. - The consumer credit-note PDF carries the original invoice's taxRateBps, which Rule 46 read with s.34 requires on the reversed head. - consumerStateCode at checkout is restricted to the real GST state codes. It only had to be two characters, and numericStateCode passes any two digits through, so "99" reached a statutory document as a real-looking state and the register filed it under a state that does not exist without a warning. - ConsumerInvoice and ConsumerCreditNote get @@index([issuedAt]); both composite indexes lead with a column the monthly period scan does not constrain. - The register workflow declares contents: read and drops the checkout credential. Action SHA-pinning was declined: no workflow in this repo pins, and pinning one is drift, not hardening. - PLATFORM_INVOICE_PREFIX moves out of "missing — money-critical", since the series falls back to FAM and issuance is unaffected. Also folds in the missing HTTP twin: the register export core is now exported and shared by the Actions entry point and app/api/cleanup/gst-outward-register-export, so both take the same fail-closed lock and an overlapping manual run answers 409 instead of racing the gapless series. The twin does not write the CSV — a serverless filesystem is read-only and nothing would collect it. Part of #1365 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(compliance): IST period boundaries, GSTIN-derived supplier state, a cumulative credit-note cap, and heads that sum to the note total Four review decisions on the B2C tax-invoice work, all of which changed a figure rather than a comment. Nothing has been exported or filed yet, so the semantics are corrected in place with no migration. An IST calendar month now runs from IST midnight to IST midnight. Both boundaries were built from UTC components, which dropped the first five and a half hours of the month and swallowed the same span of the next one; the default window for the 3rd-of-September run moves from 2026-08-01T00:00Z to 2026-07-31T18:30Z, and its exclusive end from 2026-09-01T00:00Z to 2026-08-31T18:30Z. Both compliance jobs read the shared helpers, so both move together, and the register label still prints the IST days the file covers. The platform's own state is now derived from PLATFORM_GSTIN first, because the first two digits of a GSTIN are the state of registration by law, with SUPPLIER_STATE_CODE as the fallback for a GSTIN that carries none. Reading the env var alone put IGST on an intra-state supply whenever it was unset. When the two disagree the mint fails closed, exactly as a missing GSTIN already did, because either choice burns a gapless Rule 46 number on a document that cannot be corrected in place. Credit notes are capped against the value the invoice still has left to credit rather than against its full total. A partial refund and a later lost chargeback are two distinct idempotency keys against one invoice, so neither probe short-circuits the other and the per-note cap allowed them to credit past 100% of the supply, understating the period's output tax. The note's heads are prorated from the invoice's total tax and then re-split by the invoice's own floor-CGST rule, instead of each head being floored independently. The old shape left the stored row short of its own total by a paise or two, which the register then reported as a reconciliation warning; the identity taxable + cgst + sgst + igst == total now holds by construction and no head can exceed the invoice's. Part of #1365 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * docs(payments): describe the resolved supplier state and the cumulative credit-note cap The B2C invoicing guide still said the supplier's state came from the environment variable and that a credit note was capped at the invoice total, both of which the preceding commit changed. The required-secrets table said an unset SUPPLIER_STATE_CODE stopped the intra-state split from being decided, which is no longer true now that the GSTIN is read first; what stops invoicing today is a value that contradicts the GSTIN. Part of #1365 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…rds Razorpay already has (#1386) * fix(payments): fence Stripe behind STRIPE_ENABLED and give it the guards Razorpay already has Stripe was fully live: every checkout page hardcoded an active Stripe entry, routeGateway honoured an explicit STRIPE request unconditionally, and assertGatewayUsable only rejected the DODO_PAYMENTS stub — all on sk_test_ keys. Razorpay is the primary rail for domestic and international collections and Dodo Payments is the sanctioned post-MVP international gateway, so Stripe is a contingency only and must be unreachable by accident. assertGatewayUsable now throws DisabledGatewayError for STRIPE unless STRIPE_ENABLED=true, and both doors into a live charge go through it: the checkout router and createPaymentIntent, which is what the approval-payment path uses. Auto-routing is unchanged and refunds stay outside the fence so a Payment already taken on Stripe is never stranded. The checkout UI reads NEXT_PUBLIC_STRIPE_ENABLED from one shared list instead of four hardcoded copies. core/stripe.ts also gains the guards razorpay.ts has carried since #825: the sk_test_-in-production check (in the lazy initializer, per #1376, with the next-build carve-out and a STRIPE_ALLOW_TEST_KEYS_IN_PRODUCTION opt-out), a required idempotency key on refunds passed as a stripe-node request option rather than a body param, a positive-amount check in place of `amount || undefined`, a captured-intent precondition, an explicit timeout and retry budget, a null session.url error instead of a non-null assertion, and a warn on an unmapped refund status. The Stripe charge.refunded webhook now passes providerPaymentId like the Razorpay dispatcher does. Dead lib/payments/core/transactions.ts is removed. Closes #1351 Closes #1359 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): resolve the Stripe charge id explicitly on the refund webhook `latestRefund.charge` is `string | Stripe.Charge | null`, and the Stripe dispatcher parses its body with JSON.parse, so tsc cannot catch the expanded case. When Stripe expands the field, `charge || id` handed an object to handleRefundCreated as the provider payment id, which would never match a providerPaymentId column and would land in a log line as [object Object]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a fenced gateway is a business rejection, not a 500, and Stripe.js no longer loads behind the fence Part of #1351 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): review round 1 on the Stripe fence — a restricted test key is still a test key, and a refund amount must be whole paise Part of #1351 Three review findings, all verified against the code as it stands after 5e75d36: - The production test-key guard matched only `sk_test_`. Stripe's RESTRICTED test keys carry `rk_test_` and are the prefix Stripe itself recommends for server-side use, so the more security-conscious mistake walked straight through the fence and built a live client in test mode. Both prefixes now count. Also settles SonarCloud S6557 by switching the regex to startsWith. - `refundPayment` rejected `<= 0` but nothing else, and NaN, Infinity, a fraction and an unsafe integer all compare false against zero. Those reached `paymentIntents.retrieve` and came back as a generic gateway error the caller books as a rail failure rather than the upstream arithmetic bug it is. The guard now requires a positive safe integer, matching the ledger's posting guard. - The fence documentation claimed the guard stops every Stripe payment intent. It does not: `createPaymentIntent` returns a mock intent before it consults `assertGatewayUsable`, deliberately, so the Mock Pay flow keeps working with the fence closed. Both doc surfaces now say so, and both now describe the widened test-key guard. Also extracts the typed-code lookup this PR added to `classifyError` into its own function, which puts that function back under the cognitive-complexity bar (SonarCloud S3776, 16 of an allowed 15). Behaviour is unchanged. Verified: the real `getStripeClient` and `createStripeRefund` under a mocked Stripe SDK, no database and no server. Before, `rk_test_` under NODE_ENV=production returned a built client and all four bad amounts reached the SDK; after, the key throws STRIPE_TEST_KEY_IN_PRODUCTION and every bad amount throws INVALID_AMOUNT with zero SDK calls, while a legitimate 45000-paise refund is untouched. 53 payments suites / 510 tests pass, eslint clean, prettier clean, cold tsc clean apart from the two known schema-drift files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): frozen-wallet and consent refusals reach the buyer as business errors with actionable toasts Closes #1426 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… gets a CSV instead of a console dump (#1389) * feat(compliance): org-rail TDS reaches the Form 140 draft, and the CA gets a CSV instead of a console dump Host-organisation payouts computed withholding, deducted it from the disbursement and credited TDS_PAYABLE, but never wrote a TDSRecord — so an entire rail of real statutory deductions was invisible to the filing side, and the quarterly draft carried a standing warning saying exactly that. TDSRecord and TdsAdjustment now carry either rail. markOrgPayoutCompleted writes the deduction row at COMPLETED and nowhere else, markOrgPayoutReversed nets it back out, and the draft builder groups by (deductee type, deductee id). The draft that reaches stdout stays masked; the full PAN goes only into a CSV in the private bucket, reachable through one authenticated admin hop. Closes #1354 Closes #1362 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(csv): a bare negative number is not a formula, so the TDS reversal rows keep their sign escapeCsvField prefixed an apostrophe to anything starting with `-`, which is right for a crafted `-1+1` and wrong for a plain `-500`. The TDS return CSV carries negative paise on every reversal row, so the CA's numeric column imported as text. Plain numbers now bypass the guard. Every other leading `-`/`=`/`+`/`@` value is still neutralised. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(compliance): the org TDSRecord is dated at the completion instant, not the batch markOrgPayoutCompleted took the record's financialYear from the batch-time pin on OrganizationPayout and let recordOrgTDSDeduction default the quarter to getIndianFYQuarter(). A batch built in late March that settles in April therefore filed FY 2025-26 quarter 1 — a period that does not exist. TDS on a payout is dated at payment, so both halves of the period now come from the completion instant, including the FY window the cumulative credited figure is summed over. recordOrgTDSDeduction takes an optional quarter so a caller that knows the instant can supply both together. OrganizationPayout's tdsRateAppliedBps stays pinned at batch time because it is what was actually withheld; tdsFinancialYear stays as the batch-time audit stamp. Also completes a comment in markOrgPayoutFailedInternal that lost its last words in an earlier edit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(compliance): the TDS return job reaches storage through the Node-safe core, so its scheduled entrypoint can load The generic storage helpers were added to lib/pdf/storage.ts, which sources its client from lib/supabase.ts. That module opens with `import "server-only"`, a marker whose main entry does nothing but throw outside Next's react-server resolution, so the new scheduled workflow's entrypoint died during module evaluation before a line of its own code ran (#1270) — the same invisible failure shape that had left five crons never once completing. uploadPrivateFinanceObject, privateFinanceObjectExists and createPrivateFinanceSignedUrl now live in lib/storage/private-finance-object.ts and take their admin client from the marker-free lib/supabase-storage-core.ts. lib/pdf/storage.ts re-exports all three, so the invoice PDF route is unchanged; the export job and the admin route import the leaf module directly. The workflow already declared NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY, so rule 3 of the guard was already satisfied and no secret is new. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(storage): the private finance bucket provisions itself on first use — it never existed on the live project `org-invoices` had never been created on the live Supabase project. The first end-to-end run of the quarterly TDS return export died inside `uploadPrivateFinanceObject` with `Bucket not found`, and the organization invoice PDF route carried the same latent fault since April, because both write through this helper. The original comment said the bucket "must exist; create via the Supabase dashboard" and nobody ever did. `ensurePrivateFinanceBucket` now runs before every upload. It asks the admin client for the bucket and creates it private, with a 25MB per-object limit, only when `getBucket` reports it absent, memoizing success per process. A rejection is not memoized, so a transient Supabase failure does not pin the process. A lost create race re-probes rather than matching Supabase's error wording. Not `ensureBucketExists` from the same core module: that one probes with the anon client via an object list, which cannot answer reliably for a bucket that is private by design, and it reports failure as a `false` the caller would only discover later at the upload. The `invoices` bucket that does exist is not this one renamed. It predates the `org-invoices` name by a month, holds a single object under a different path scheme whose id matches no row in `users`, `ConsumerInvoice` or `OrganizationInvoice`, and no code in the repo has ever referenced it. Part of #1354 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(compliance): the return draft targets the quarter that CLOSED, and the full-PAN CSV is ADMIN-only CodeRabbit round 1 on #1389. - The scheduled workflow fires on the 5th of the month after a quarter ends, but the job derived its period from "today", so the 5 April run exported five days of the new FY instead of the FY 2025-26 Q4 that was actually due. `closedIndianFyQuarterOf` derives BOTH halves of the period from one instant, and the workflow inputs plus the filing doc now say what it does. - The §393 payment-code window used an inclusive UTC-midnight bound, so a rate row effective at the next quarter's start won the lookup. Seeded rate rows are dated `T00:00:00+05:30`, so the 1 Apr 2026 IT2025 row was being applied to FY 2025-26 Q4 — exactly the misclassification the comment above it forbids. The bound is now exclusive and IST-aligned. - The full-PAN CSV route admitted STAFF through `requirePrivilegedAuth`; it is the same decrypted-PAN class of data `/api/admin/tds?view=form26q` has always gated on ADMIN. Its query is now a Zod schema that rejects "2foo" and a non-consecutive FY label instead of silently naming another quarter's object. - The `view=form26q` rows carry the org rail's identity (type, organization id, deductee name, org PAN) rather than a null consultant id and a null PAN. - S3776/S3358 on the export job: the period resolution, deductee identity fetch, baselines, payment-code lookup, row assembly and PAN decryption are named helpers, and the deductee ternary is one of them. No behaviour, lock key or job name changed. Left for a decision: `markOrgPayoutCompleted` still skips `recordOrgTDSDeduction` when a payout carries TDS with a null `tdsRateAppliedBps` (lib/payments/payouts/org-payout-service.ts:1158) — admitting those rows means making `TDSRecord.tdsRateBps` nullable, which changes what reaches the return. Part of #1354 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payouts): an org payout that withheld TDS without a stored rate is reported loudly instead of skipped silently `markOrgPayoutCompleted` gated the statutory `TDSRecord` write on `payout.tdsRateAppliedBps` being truthy. A payout batched before that column existed carries a positive `tdsAmountPaise` and a null rate, so the completion still credited TDS_PAYABLE while the filing row was dropped without a trace — withholding that never reached the quarterly return. The rate stays non-null on `TDSRecord` and nothing is backfilled: those pre-column rows are the whole nullable population, pre-MVP data is reset before launch, and every payout batched from here on carries the rate. Instead the gap now pages. After the transaction commits — the payout must still succeed, since the cash has moved — the service calls `recordSystemError` under the PAYOUT category with the payout id, the organization id and the withheld amount, and mirrors it to Sentry at warning level through `reportSentryMessage`. The rate is deliberately not reconstructed from `tdsAmountPaise / gross`, because `computeTdsForPayout` floors its product and that division cannot invert it exactly. The gate itself becomes an explicit null-and-positive check, which is the same set of values the old truthiness test admitted. Part of #1354 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * style(docs): prettier-format the three TDS docs this PR already touches Part of #1354 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… payment id, deferred webhooks page, and the post-payment channel leg is re-driven off the appointment row (#1391) * fix(payments): refunds and disputes find their Payment by the gateway payment id, deferred webhooks page, and the post-payment channel leg is re-driven off the appointment row Closes #1352 Closes #1353 Closes #1356 Part of #1358 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(webhooks): the either-id lookup keeps the old reachability — soft-deleted payments still take their refund and dispute events The `findFirst` that replaced `findUnique({ where: { paymentIntent } })` in handleRefundCreated and handleDisputeCreated carried a `deletedAt: null` filter the original lookup never had. That is a semantic narrowing on a money path: a Payment soft-deleted after capture would stop matching, so its refund event would DEFER and be given up on after 168h, and its dispute would page as CRITICAL_DISPUTE_UNLINKED with earnings left payable. Nothing about adding a second key justifies excluding those rows. The OR-match now reaches exactly what the old lookup reached, plus the gateway payment id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * test(security): the DM-precedence source pin follows the channel block to ensure-channels.ts Two suites assert against `lib/payments/webhooks/handlers.ts` as source TEXT, so extracting the Stream channel block into `ensure-channels.ts` left them reading a file that no longer contains what they pin. Both entries now name the new module; every assertion is kept, because each one is still true there — the code moved, the contract did not. The trial rung's expected substring changed with it: the extracted function early-returns on a missing appointment, so the chain reads `appointment.trialSession?.consultantProfile` rather than optional-chaining off the lookup variable. The negative assertion — that it is NOT the plan author's `trialSession?.subscriptionPlan?.consultantProfile` — is untouched, which is the part that was ever load-bearing. Two other suites also read handlers.ts as text and were verified unaffected: reschedule-respond pins the Novu booked-notification block and participant-shadow-write pins the participant edge, neither of which moved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a Razorpay error envelope with no body maps to a RefundError instead of a TypeError `handleRazorpayRefundError` and `handleRazorpayError` both guarded with `"error" in error`, which also passes for `{ error: undefined }` — the key is an own property even when its value is not there. Reading `.code` off it then threw `TypeError: Cannot read properties of undefined`, which escaped the handler that exists precisely to classify the failure. Live E2E on 2026-09-04 lost 4 of 8 reconcile-refunds attempts to that TypeError instead of recording a classified RefundError. Both handlers now read the envelope through a shared helper that requires the body to be a non-null object and its `code`/`description` to be strings, so an empty or malformed envelope falls through to the generic error. The 409 to REFUND_IN_FLIGHT mapping in `postRefund` is unchanged. Part of #1353 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the client-confirmation audit row is written inside after(), and the chat-leg queue gets an index Review round 1 on #1391. The `recordSystemEvent` call that records a CLIENT-side confirmation was floated next to the response rather than scheduled with `after()`. On Netlify the invocation freezes once the response is sent, so a detached insert races that freeze — and the confirmations worth auditing are the slow ones, exactly the ones whose audit row could be dropped. It now runs inside the existing `after()` callback, awaited ahead of the pipeline, keeping its `.catch` so it stays best-effort and can never fail a confirmation. `Appointment` had no index able to serve the #1356 chat-leg work queue, which filters `chatChannelEnsuredAt IS NULL AND deletedAt IS NULL` and orders by `createdAt`; every existing index leads with a different column. Prisma cannot express the partial index this really wants, but leading with the two equality columns gets the same seek. The high-`deferCount` refund runbook entry claimed such an event almost always means the payment was never captured. A pre-`gatewayPaymentId` row whose `payments.fetch` translation fails on credentials or availability defers identically, so the entry now sends operators to the local capture state first and to the gateway lookup second. The `ensure-channels` header now states which buyers the sweep actually re-drives: the stamp is per appointment, so for a shared `WEBINAR` or `CLASS` a later buyer whose ensure fails after the row is stamped is caught by `syncUserEventChannels` on their next dashboard load rather than by the sweep. Part of #1353 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a refund idempotency key is rejected rather than rewritten, and the channel re-drive pass has a buyer-operation budget postRefund used to strip the characters Razorpay rejects out of the idempotency key before sending it. That is lossy: two distinct keys can collapse onto one header value, and Razorpay answers the second refund with the first one's result. The key is now validated whole against the documented rule and refused with REFUND_IDEMPOTENCY_KEY_INVALID when it does not match. The only production caller passes Refund.id, so nothing changes in practice and the failure mode is closed. The chat-channel pass of the reconcile sweep took the operator's limit, which reaches 500 appointments, and spent one outbound Stream call per paid buyer of each — unbounded work against the ticker's function ceiling. It now caps at 100 appointments and 500 buyer operations and stops cleanly when the budget is spent; rows it did not reach keep a NULL chatChannelEnsuredAt and the next run resumes oldest-first. Both counts are reported in the run summary. Part of #1353 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the manual-recovery stamp is a compare-and-set, so a late capture cannot resurrect an expired payment Both REQUIRES_MANUAL_RECOVERY branches of handlePaymentSuccess — the capture-amount mismatch and the metadata-validation failure — stamped SUCCEEDED with a bare `tx.payment.update({ where: { id } })`. When the abandoned-payments sweep had already expired the row and released its tentative hold, a late `payment.captured` flipped the EXPIRED payment back to SUCCEEDED, leaving money recorded against an appointment that stayed tentative with nothing left to release it. Every status stamp in this pipeline now rides a compare-and-set: the `paymentStatus: PENDING` predicate sits in the WHERE of an `updateMany`, as ADR 21 requires. A count of zero means the row is already terminal, so the handler writes nothing, records a PAYMENT system error and a Sentry warning naming the order, the current status and the reason, and returns the same result it returned before — the webhook is still acknowledged and Razorpay does not retry it. Two further stamps of the same shape take the same guard: the confirmation write itself, which a replay now reaches, and the post-transaction stamp on the legacy capture that loses the GiST overlap race. The dev replay route built its metadata under the key `consulteeId` while the schema requires `userId`, so every replayed first capture failed validation and took the recovery branch instead of confirming the booking. Renaming the key sends it down the confirmation path, which is why that path needed the guard in the same change. Closes #1439 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): post-commit notifications and the chat-channel step are bounded, so after() work cannot starve the single-connection pool Phase 2 of handlePaymentSuccess runs inside after(), on the same warm instance that is already serving the next inbound request, and PG_POOL_MAX=1 means the two share one Prisma connection. Two unawaited Novu triggers ran 39 s each while the chat-channel step waited for that connection and died at the 3 s connect timeout. The triggers are now collected and awaited together, each under a 5 s deadline, before the channel step begins; the channel step has the same deadline and, on timeout, leaves chatChannelEnsuredAt NULL so reconcile-orphaned-confirmations re-drives it. The Novu client gets an explicit request timeout and a bounded retry budget, so an abandoned call cannot keep burning the instance. The ensure-channels read is a narrow select of the ids, org ids and consultant userId the step uses instead of a six-relation include. Money is untouched: the Serializable transaction commits before any of this runs. Closes #1446 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * refactor(payments): one compare-and-set stamp helper for the capture handler, and a smaller channel pass Extracts the identical payment->appointment->consultantProfile resolution that the webhook success path and the checkout mock/zero/sponsored path both ran before creating earnings into resolvePaymentForEarnings, and pulls the channel-pass loop body in the orphaned-confirmation reconciler into ensureChannelForOrphan so the sweep function's cognitive complexity clears the gate. No behaviour change. Part of #1439 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(sonar): exclude test suites from copy-paste detection in Automatic Analysis too .sonarcloud.properties is what SonarQube Cloud Automatic Analysis reads, not sonar-project.properties (that one is staged for the future CI-based scan). #1330 added the CPD exclusion for __tests__/**,tests/** only to the latter, so the new-code duplication gate kept counting per-file jest.mock scaffolding as duplication against every PR that added test coverage — this PR's own gate failure is that boilerplate, not application code. Part of #1439 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a failed RazorpayX payout reaches FAILED, and a reused refund idempotency key stops pretending to be a race Round-2 review triage on this PR plus two findings from the Razorpay productionization audit (#1451). The consultant payout status map had no `failed` entry, so a `payout.failed` delivery fell through to the `|| "PENDING"` default: the payout stayed in flight and its earnings stayed BATCHED, because the un-batch back to READY only runs on the FAILED branch of handlePayoutWebhook. The Stripe twin ten lines below already mapped it. One compact pin covers the consultant path. postRefund retried every 409, but Razorpay answers 409 for two conditions and only one of them is worth waiting on. A key replayed with a different payload answers 409 for as long as the key lives, so the retry bought a wasted second and reported the wrong cause; it now throws immediately as REFUND_IDEMPOTENCY_KEY_REUSED. The key material is untouched. reportTerminalCaptureRace re-reads the payment status instead of echoing the caller's pre-read, which is the doctrine confirmApprovalStatus has followed since #844 — the state named in that report is what an operator reconciles against. Callers pass their own client so no global-client read happens inside a transaction (#1435). The dev mock-webhook gate loses its `VERCEL_ENV === "preview"` disjunct: it was a runtime toggle contradicting the build-time posture the same comment claims, and it was dead anyway because this app deploys on Netlify. Part of #1353 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ys so when it shows an estimate, and the rate provider gets its attribution (#1414) * fix(payments): settlement is INR at the gateway boundary, checkout says so when it shows an estimate, and the rate provider gets its attribution A non-INR currency could reach Razorpay order creation through the org wallet top-up: `BillingAccount.currency` accepted USD/EUR/GBP and was forwarded verbatim alongside an amount in INR paise, so a 100000-paise (₹1,000) top-up went out as a $1,000.00 order. `assertInrSettlement` now runs as the first statement of `createRazorpayOrder` and `createStripeCheckoutSession`, and the three admin-facing currency schemas are narrowed to `z.literal("INR")`. On the display side, all four checkout pages rendered the Total through a live FX conversion while the gateway charged INR and the confirmation email said INR, with nothing on the page disclosing the gap. `useCurrency` now reports `isEstimate`, degrades `currency`/`symbol` to INR along with `rate`, and a shared `FxEstimateNote` under each Total names the INR amount the gateway will take. `displayCurrency` is allowlisted against a shared, React-free code list instead of any three-letter string. The rate provider is bounded and credited: the endpoint is configurable, a cache older than 24 h is refused so the client degrades to honest INR, `/api/currency` is CDN-cached and IP rate-limited, and the licence-required attribution appears beside the navbar switcher and in the estimate note. Also corrects the IBT claims in the gateway router (an INR order paid by an overseas card, not a bank-transfer product), scales `refundPct` to integer bps in event refunds, and deletes six dead FX helpers. Closes #1396 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): a wallet- or licence-funded booking that already succeeded never opens the gateway widget Closes #1437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the FX estimate names the rail that will actually take the money Review round 1 on #1414. - /api/currency allowlists `to` against SUPPORTED_CURRENCY_CODES with zod instead of indexing the provider's ~160-code table, and stops echoing the raw query value into the 400 body. - getExchangeRates bounds its provider fetch with AbortSignal.timeout and treats a timeout like a 5xx: serve the young cached copy, else throw. - FxEstimateNote branches on the selected org's funding source. WALLET debits the credit pool, INVOICE defers to NET-X billing and LICENSE charges nothing, so none of them should have read "you will be charged ... by the payment gateway". The provider attribution stays on every branch. - The navbar's rate attribution is `lg:inline`; at `xl` it was on no surface at all between the desktop bar and the `lg:hidden` drawer. - The multi-currency doc's international settlement row is T+7, per Razorpay's own FAQ and our gateway evaluation, and the "zero-fee UPI" claim is corrected to zero MDR with the 2% platform fee plus GST still payable. Part of #1396 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): the gateway SDK loads only for a rail that needs it, and the INR guard hands back the canonical code Part of #1396 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): the Total row and its FX note are one shared component, closing the SonarCloud new-code duplication gate The Total-row + FxEstimateNote pair rendered identically on all four checkout pages; folding it into CheckoutTotalRow means each page adds one call instead of a repeated multi-line block, which is what the new-code duplication gate on PR #1414 was flagging. Part of #1396 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * Revert "fix(checkout): the Total row and its FX note are one shared component, closing the SonarCloud new-code duplication gate" This reverts commit c3ab9b6. * chore(sonar): exclude test suites from copy-paste detection in Automatic Analysis too .sonarcloud.properties is what SonarQube Cloud Automatic Analysis reads, not sonar-project.properties (that one is staged for the future CI-based scan). #1330 added the CPD exclusion for __tests__/**,tests/** only to the latter, so the new-code duplication gate kept counting per-file jest.mock scaffolding as duplication against every PR that added test coverage — this PR's own gate failure is that boilerplate, not application code. Part of #1439 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): round-2 review triage plus the Razorpay order-notes limits Part of #1396 Round-2 inline comments on #1414. Fixed: the FX estimate note promised a gateway charge on a zero total that never reaches a gateway (and the branch chain is now early returns, which also clears the nested-ternary gate); `loadScript` REJECTS on `script.onerror`, so the load-failure toast was unreachable and buyers saw the generic message; `res.json()` sat outside every stale-cache fallback in `getExchangeRates`, so a malformed 200 answered 500 with a young cache in hand, and a payload without `rates` published `undefined`; `handleCheckout` cognitive complexity 17 against a ceiling of 15; the navbar drawer animated through a reduced-motion request and its close button had no accessible name. From the Razorpay productionization audit (#1451): Razorpay caps an order's `notes` at 15 keys and 256 characters per value, and the buyer's booking note was forwarded verbatim with no bound anywhere — a long note made the order impossible to create, which a buyer experiences as being unable to pay at all. `checkoutSchema` now bounds it with a message they can act on and `buildPaymentMetadata` truncates as a second line of defence; the full note is still persisted on the Payment and Appointment rows. `discountCode` is dropped from the gateway payload because the org-sponsored event case emitted exactly 15 keys with no headroom, and nothing reads it back. `BAD_REQUEST_ERROR` is Razorpay's generic 4xx class and was reported as an authentication failure, sending operators to rotate keys that were fine; only auth-shaped payloads keep that wording now. `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` is documented in both the required-secrets table and `.env.sample`. Left open as needs-decision: the webinar page showing a payable total under LICENSE funding, because the client cannot know whether an ACTIVE ProgramAssignment will actually absorb the booking. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…te-card scope has one open window, and invoice-number collisions page (#1434) * fix(payouts): the stuck-payout retry moves status through a CAS, a rate-card scope has one open window, and invoice-number collisions page The stuck-payout handler re-armed a retry with a bare update while every sibling write in the same loop was a CAS. The cohort is read once and each payout then costs a gateway round-trip, so a concurrent process-payouts run or a payout webhook can move a later element; the unguarded write stamped it back to APPROVED and the next batch paid it twice. The reset now carries status=PROCESSING and providerPayoutId=null in its WHERE, and a zero count is reported as skipped rather than retried or thrown. bumpRateCard is a read-then-write that ran at the default isolation level, so two concurrent bumps on one scope could each leave effectiveTo=null and findEffective picked between the open windows non-deterministically. The POST route now runs Serializable under withSerializableRetry, the new partial unique index rate_card_one_open_window makes the invariant structural (keyed on COALESCE expressions because three of the four scope columns are nullable), and a surviving collision answers 409 RATE_CARD_OPEN_WINDOW_CONFLICT. A duplicate invoice number in the subscription-invoice cron was a console.warn in a job nobody reads. No number burns — the counter reservation shares the same Serializable transaction — so this is alerting only: the collision now reaches Sentry and SystemEvent with the subscription and the competing number. The clawback dual write had no detector: reversePayoutClawback posts its reversal best-effort inside a try/catch and the two other writers of clawbackAmountPaise post nothing, so a payout could claim recovered cash the journal never saw. The reconciler emits LEDGER_DUAL_WRITE_GAP for it. Closes #1407 Closes #1405 Closes #1401 Closes #1408 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(db): the open-window index treats NULL scope columns as equal with NULLS NOT DISTINCT An enum-to-text COALESCE expression is not IMMUTABLE, so Postgres refused the expression index; NULLS NOT DISTINCT (Postgres 15+) gives the same guarantee on the bare columns. Applied to the live project; the sidecar guard passes. Part of #1405 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * docs(money): the open-window index is described as NULLS NOT DISTINCT Part of #1405 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(finance): one Sentry capture per invoice collision, chunk the clawback lookup Round 1 of review triage on #1434. - The P2002 branch reported twice: reportSentryError and then recordSystemError, which escalates to Sentry itself and drops `context` on the way. The durable row now goes through recordSystemEvent so the collision extras survive on the one capture that carries them. - The #1408 clawback lookup fanned every payout id into a single IN, which a full-scope run can push past the bind-param cap; chunked at 5,000 like the sibling lookups below it. - Pinned the winning side of the #1407 retry CAS: count 1 re-arms once and the retryCount bump rides the guarded write. Part of #1407 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(reconcile): the dual-write gap check compares cumulative clawback postings, not presence `clawbackAmountPaise` is a running total, so a payout clawed back twice whose second `Dr CASH / Cr ORG_PAYABLE` posting was swallowed still carried a `clawback:*` transaction and the payout-id Set read it as clean. The CASH DEBIT leg is the authoritative side — it is the money that came back — so the summed legs are what the stamped counter is measured against now. Total and partial gaps keep the one kind and differ in `deltaPaise`. The finding builder is extracted pure so the pin drives it without standing up a reconciler run; the 5,000-row chunking is unchanged. Part of #1408 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the RazorpayX pollers authenticate as RazorpayX, and two jobs get their HTTP twins Round 2 triage on #1434 plus the fold-ins verified elsewhere today. - handle-stuck-payouts: the RAZORPAY arm of mapGatewayStatus had no `failed` case (Stripe's has), so a bank-refused payout fell through as an unknown status and was skipped — PROCESSING forever with its earnings still linked. Delegates FAILED to handlePayoutWebhook now. - Both payout status pollers read RAZORPAY_KEY_ID/RAZORPAY_SECRET, i.e. the checkout merchant, not the RazorpayX one. One exported resolveRazorpayXCredentials is now shared with the disbursement path, and the pre-flight "configured" gate tests the credentials the lookup sends. - checkout: DomainVerificationRequiredError fell through to classifyError and answered 500 UNKNOWN_ERROR. Typed 403 + a toast naming the admin action, registered in BUSINESS_ERROR_CODES and the toast map. - settle-invoice-accruals and tds-26q-draft-export are the last two jobs with no CRON_SECRET HTTP twin. Both cores are exported around their own cron lock; the TDS job's main() ran at import and is now behind require.main === module. - Reviewer asks addressed: awaited the P2002 audit insert so it cannot lose its race with $disconnect, and pinned the non-positive clawback counter. Part of #1407 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payouts): the payout-status reconciler maps a failed gateway payout to FAILED, and the clawback detector refuses unsafe integers The RAZORPAY arm of reconcile-payout-status's mapGatewayStatus had only `rejected`, so a payout the bank refused after it was queued fell through as an unknown status and was skipped — which is precisely the cohort this sweep walks. It sat PENDING/PROCESSING forever with its earnings still batched against money that never left. FAILED delegation un-batches them and reverses the TDS, and the failure reason carries the gateway's own text without the pre-completion net-zero note. The clawback dual-write detector keeps its `number` narrowing (Finding is JSON-shaped and consumed as numbers), but a value that does not survive it can no longer pass quietly: past 2^53 the shortfall comparison rounds and a real gap reads as clean. Both the stamped counter and each posted CASH leg are now checked with Number.isSafeInteger and throw naming the payout. Part of #1407 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* chore(ci): every scheduled workflow carries a concurrency group, and the registry test keeps it that way Closes #1413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * chore(ci): the newer scheduled workflows get their concurrency group, and the review round-1 notes are addressed Part of #1413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ounded RazorpayX client, and an inbound webhook-secret rotation grace (#1451) * fix(razorpay): productionization pass — terminal payout statuses, a bounded RazorpayX client, and an inbound webhook-secret rotation grace Audited every Razorpay surface against current dev (post #1385/#1390/#1391 groundwork) using the razorpay skill references and the razorpay-* agent checklists, then fixed the legit pre-MVP items that do not belong to an in-flight PR. - `mapPayoutStatus` treated the terminal RazorpayX status `failed` as an unknown string and returned PENDING, so a payout the bank refused never reached FAILED and its earnings stayed BATCHED. - Every RazorpayX HTTP call used a bare `fetch` with no timeout, which can wedge a payout batch that holds a cron lock, and flattened Razorpay's `{ error: { code, description } }` envelope into one opaque message. - Rotating `RAZORPAY_WEBHOOK_SECRET` was a hard cutover. Razorpay disables a webhook that has failed for 24 hours and lost events cannot be replayed, so `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` now gives the rotation a grace window, mirroring ADR 09's outbound posture and reporting every delivery that actually lands on the old secret. Docs gain the missing Razorpay go-live checklist and the payout-status and idempotency-key corrections. Part of #1377 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payouts): the X-Payout-Idempotency header stays inside RazorpayX's 36-character bound, and rotation grace needs a current secret Review triage on #1451. RazorpayX accepts an `X-Payout-Idempotency` value of 4-36 characters and answers anything else with a 400, so both money-out paths were sending a header the gateway could never accept: an organization payout derives `payout_<uuid>` at 43 characters, and a consultant payout prefers the `idempotencyKey` persisted on the row, `payout_<profileId>_<batchId>`, at 72. `boundPayoutIdempotencyKey` folds any key the gateway would refuse onto a 34-character digest of itself at the point the header is written. The fold is a pure function of the key, so the property that makes a retry safe survives untouched — the same row always derives the same slot, and a request that timed out after RazorpayX accepted it returns the original payout rather than paying twice. The persisted key is deliberately left alone: it is also the row's unique constraint and the Stripe transfer key, and neither is bounded this way. The RazorpayX success body was read outside the `fetch()` try, so a reply whose headers arrived but whose body stalls tripped the same AbortSignal there, and a non-JSON body threw a SyntaxError. Both escaped as bare exceptions and lost the retryable code the payout callers classify on; both are now normalised to RAZORPAYX_REQUEST_FAILED, which is the honest reading since neither says whether the payout was accepted. `resolveRazorpayPaymentSecrets` returned the previous secret on its own when the current one was unset. The grace window is an aid to a rotation, not a secret in its own right, so a deployment that has lost `RAZORPAY_WEBHOOK_SECRET` must fail loudly on the route's 500 instead of quietly accepting deliveries signed with a value the operator has retired. The webhook setup table omitted `payout.failed` — the terminal event that tells the platform a bank refused a transfer — along with two dispute events, so an operator following it would have configured six of the seven payout events the dispatcher handles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ebit, never by inflating the payment (#1460) * fix(enterprise): a wallet-funded overage is collected by the wallet debit, never by inflating the payment, and cap and member-overage refusals reach the buyer as business errors On the WALLET rail the debit taken when a booking commits is the whole nominal price, so an over-cap booking is already paid for. The CHARGE_ORG branch nonetheless carved from an INVOICE_ACCRUAL leg that a wallet parent never has, fell into its own "not reachable" additive fallback, wrote an OVERAGE_INVOICE_ACCRUAL leg and incremented Payment.amount — which broke the leg-sum identity and made a later cancellation refund the org more than its wallet was ever debited. The wallet rail is now resolved first and records the OverageEvent as CHARGED and settled against the payment whose WALLET leg collected it, with no leg and no amount change. A surcharge, or an org-sponsored payment carrying none of the three funding legs, fails closed with a business error instead of inflating the amount. CHARGE_MEMBER on a WALLET account is refused at programme create and patch time, and the checkout backstop now carries a stable code and a 409. PROGRAM_CAP_EXHAUSTED and the per-assignment session cap reach the route with their own status and toast, because the checkout catch rethrows any error whose code is registered in BUSINESS_ERROR_CODES. The refund-reconcile sweep skips STRIPE rows while the rail is fenced and counts them, instead of failing the run. Closes #1458 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a CHARGE_ORG overage surcharge is credited to platform revenue, and the licence rail refuses an overage it can never collect Sentry FAMILIARISE_WEB-28 fired on the #1458 payment: the BOOKING posting is Dr funding legs + a DISCOUNT plug clamped at >= 0 against Cr legs all derived from Payment.originalAmount + taxAmount, so it balances only while the funding legs sum to no more than the nominal gross. The inflated wallet payment overshot by the marginal, threw LedgerImbalanceError and the booking committed with no journal entry — which is what let the inflated refund through. Removing the extra leg fixes the wallet rail by construction. The invoice rail was unbalanced by exactly surchargePaise for the same reason: the carve keeps basePaise inside the price, but marginal = base + surcharge raises the accrual leg and Payment.amount by money that sits outside originalAmount. That surcharge is a markup the platform charges the org for exceeding its own cap, not consultant income, so the posting credits it to PLATFORM_FEE — no new ledger account and no change to what Payment.amount means. The licence rail cannot be balanced at all: a licence leg is deliberately zero while Payment.amount stays at full price, and the leg-sum guard excuses that only while the licence leg is the payment's only funding leg, so an overage leg re-armed the comparison and assert_payment_legs_ok raised at COMMIT. It is now refused at programme-config time and fails closed at checkout with a business error instead of an opaque database violation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a wallet overage surcharge is refused at configuration time, the ledger reconciler keeps invoices mandatory for accruals, and two org-sponsorship refusals answer with their own status Review triage on #1460 plus the #1467 fold-in. `overageBehaviorUnsupportedReason` now takes `overageSurchargeBps`, because the surcharge rather than the behaviour is what decides collectability on the wallet rail: the plain over-cap marginal is a slice of the price the wallet debit already took, while a markup on top of that price is money no rail collects afterwards. `recordWalletCollectedOrgOverage` already refuses it, but only at checkout, after the member has picked a slot — so both the create route and the merged-config patch route now refuse the configuration instead, which is the guard the settlement module's own docstring claims exists. The ledger reconciler's (G2) link check had one predicate covering ACCRUED and CHARGED, so the payment-link exception added for wallet-collected overages also suppressed findings for ACCRUED events. ACCRUED means "billed on an issued invoice" and only the rollup produces it, always stamping the line item, so that branch keeps `invoiceLineItemId` mandatory; the CHARGED branch accepts a payment link only when the payment behind it actually carries the WALLET leg that did the collecting. Closes #1467: the no-active-assignment refusal and the dunning-suspend gate both threw bare Errors, so `classifyError` fell through to UNKNOWN_ERROR and answered 500. A member whose organisation's contract had merely lapsed could not tell the refusal from a crash, and every one of them opened a Sentry incident. Both now carry a stable code — `PROGRAM_ASSIGNMENT_INACTIVE` (409) and `BILLING_SUSPENDED_DUNNING` (402), the latter on the in-lock re-check of the same gate too — registered in `BUSINESS_ERROR_CODES` with toasts that name the admin who can unblock the booking. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…unning-suspended wallet is visible before checkout (#1457) * fix(booking-ui): held bookings show their deadline, and a frozen or dunning-suspended wallet is visible before checkout Closes #1428 Closes #1427 Closes #1430 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(booking-ui): the hold countdown floors instead of rounding up, and the held row stops nesting a button inside a button The countdown sat on a payment deadline but used Math.ceil, so it promised time the hold did not have — "2m left" with 61 seconds to go — while the hook's own contract documented a floored value. Floor it, and word the final minute as "under a minute left" so the honest 0 does not read as a lapsed hold next to a live CTA. The held timeline row also carried role="button"/tabIndex/onKeyDown around the native "Complete payment" button, giving assistive tech two activation targets for one action (SonarCloud flagged the same span). The row is now inert markup and the button is the only control. Refs #1428 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(booking-ui): a lapsed tentative hold offers a new checkout instead of a dead "Pay now" Past Payment.expiresAt the hold is already dead for availability — buildDeadHoldFilter (utils/slotAllocation/occupancyPolicy.ts) counts a PENDING payment with a lapsed window as free, so another buyer can take the slot before any sweep flips the row. Checkout also refuses to resume a stale order (findReusablePendingOrderPayment matches only expiresAt > now) and mints a fresh one instead. Paying the old link would therefore capture onto a released slot and land in the #1439 terminal-race refund. So the lapsed state now replaces the CTA rather than hiding it: one sentence saying the window closed and the slot is released unless they book again, plus a "Start a new checkout" action back to the consultant's profile. It points at the picker, not a deep link to the old slot, because that time may already be gone. Both CTA sites — the timeline's held row and the payment card — now read one `tentativeHoldCta` helper so they cannot drift. The hold derivation moved above the loading/error returns since useHoldCountdown is a hook. PAY_NOW's role-agnostic behaviour is unchanged; no server or API change. Refs #1428 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…d balance, the disputes sweep reports its failures, and the abandoned-payments sweep fits the ticker budget (#1461) * fix(enterprise): wallet credits and debits are null-safe on the cached balance, the disputes sweep reports its failures, and the abandoned-payments sweep fits the ticker budget `BillingAccount.walletBalance` is nullable and every INVOICE-funded account carries NULL, so `walletCredit`'s `{ increment }` evaluated to NULL and no-opped while the ledger CREDIT posted — permanent cache-vs-ledger drift that only the reconciler noticed. Both helpers now write a zero over the NULL in the same transaction before the arithmetic and read the balance back off the mutated row instead of coercing it with `?? 0`. The related items from the same wave-1C run ship with it: the disputes reconcile route no longer hardcodes a 200 over a failed run, and the sweep counts STRIPE-gateway disputes as `skippedFenced` when the gateway fence is shut rather than failing on a gateway we deliberately turned off; the abandoned-payments sweep runs its gateway cancels five at a time with a per-call timeout and the ticker sends it a limit of ten, so it fits the six-second per-target budget; `reconcile-orphaned-confirmations` uses the shared `parseLimitParam`; and the Razorpay webhook refuses a body over 256 KB before it reads the signature. Closes #1459 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): an abandoned payment expires even when its gateway cancel fails, and the failure fails the run (#1464) The abandoned-payments sweep skipped the PENDING→EXPIRED CAS for any payment whose gateway cancel threw, while the same transaction still restored the referral credits and released the slot or seat. The Payment then sat PENDING for ever: its hold was gone, so nothing about it looked abandoned to a later sweep, the credits were handed back on a live payment, and the admin pending figure inflated with rows no job would ever heal. Because only thrown exceptions reached errorCount, the run reported success: true. The expiry no longer depends on the cancel. The Razorpay arm has always expired without a real cancel, since an order cannot be cancelled, and a capture landing after the row is EXPIRED is the terminal race #1439 owns. A failed cancel is now recorded in errors AND counted, so the run reports success: false, and the HTTP twin maps that to a 500 through the shared statusFor instead of forcing 200. The CAS-miss "skipped" semantics are untouched. The Stripe arm also stops building a raw client around STRIPE_SECRET_KEY outside the #1386 fence and its test-key guard. With STRIPE_ENABLED unset it makes no gateway call and logs once per run, which is "nothing to cancel" rather than a failure; with the fence open it goes through the fenced getStripeClient, expires a checkout session or cancels a payment intent by id shape, and treats resource_missing or an already-terminal intent as nothing to cancel. The #1459 concurrency ceiling and abort timeout are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): the webhook body cap bounds the read, and an empty ?limit= is junk not absent Review triage on #1461. The 256 KB webhook cap was enforced only against Content-Length, which the caller chooses: omitting it, or sending chunked, left `req.text()` free to buffer whatever arrived before the HMAC could reject it. The raw body is now read through a counting reader that abandons the stream the moment the cap is passed, so the limit holds against the unauthenticated caller it was written for. The header check stays in front of it because an honest oversized delivery should still cost us zero bytes. `parseLimitParam` treated `?limit=` as an absent limit, because it tested the raw value for truthiness rather than for a missing key. That is a caller that meant to bound a ticker sweep and sent nothing, and it got the unbounded batch back — the silent fall-through the shared parser exists to make visible. Only `null` is absent now; an empty value is INVALID_LIMIT like any other junk. Two pins follow the code they cover. The wallet pin only exercised the credit path, so it now also asserts that seeding a NULL cache to zero does not become a licence to spend: the debit's gte guard still refuses. The webhook pin asserted against a signature helper the route does not import, which made it vacuous; it now mocks the route's real signature module, carries a signature header so every later step would otherwise run, and covers the undeclared-size stream directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): an uncancellable but still-live Stripe intent fails the sweep Review triage on #1461, comment 5. `payment_intent_unexpected_state` was read as "already gone" and suppressed. It does not mean that. It means the intent's current state forbids a cancel, which covers a `canceled` or `succeeded` intent — genuinely nothing left to do — and equally a `processing` or `requires_capture` one, where Stripe is still holding the buyer's money. Suppressing both left the second case invisible: the run counted no failure and reported `success: true`, so the HTTP twin answered 2xx while a live gateway intent sat behind a payment this sweep had just marked EXPIRED. Stripe attaches the offending intent to the error, so the two are told apart off the payload rather than by spending a `retrieve` round trip out of the 4 s per-cancel budget. Only `canceled` and `succeeded` stay suppressed; any other status, and an absent one, is now recorded and counted, because unproven is not the same as safe. The status is read from both the top level and `raw`, since which one carries it depends on the SDK's error wrapping. #1464 is untouched: the PENDING to EXPIRED CAS still runs regardless of the cancel's outcome, and #1439 still owns a capture that lands after it. What changes is only whether an operator is told, and that state is one they should see. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…d no longer blocks their own resume (#1462, #1463) (#1465) * fix(payments): empty gateway notes are omitted, and a buyer's own hold no longer blocks their own resume (#1462, #1463) Two B2C checkout defects found by the wave-1A edge-case run, both of which end with the buyer unable to complete a purchase they already started. #1462 — buildPaymentMetadata always emitted `startsAt`/`endsAt` (and four other optional fields) into the gateway order notes, falling back to the empty string. A subscription bought for a scheduling period carries no direct slots, so those keys reached Razorpay as `""`, and `z.string().datetime().optional()` accepts an ABSENT key but rejects an empty string: every capture webhook for such a sale failed validation and stamped the payment SUCCEEDED / REQUIRES_MANUAL_RECOVERY with the buyer already charged. The builder now omits an optional field that has no value, and validateWebhookMetadata strips empty-string entries before normalizing and parsing, because a Razorpay order never expires and the orders already minted with `""` keep replaying. #1463 — validateSlotAvailability rejected any overlapping live hold, including the requesting buyer's own PENDING hold on the very same slot and plan, and it runs before the open-order resume (Rec C, findReusablePendingOrderPayment) that exists to finish exactly that order. A buyer who dismissed the gateway modal was walled out until the hold expired. Both blocking steps, and the consultee-side conflict check inside the checkout lock, now subtract a SELF-HOLD: same buyer, same plan, still-PENDING and still-live payment, and exactly the requested window. Everything else keeps blocking. Three supporting corrections come with it: the helper's buyer parameter was being handed a ConsulteeProfile id and compared to Payment.userId, so the duplicate-hold step could never fire; the resume window gate read only the first 30-minute atom of a booked run, which rejected every consultation longer than half an hour; and superseding an open order expired its Payment while leaving the appointment and slots on the calendar, which put the next attempt back into the same wall. The release now runs in one transaction with the payment CAS and goes through the guarded transitions in lib/booking/transitions.ts. Also downgrades the "invoice already fully credited" credit-note refusal from an error-level Sentry issue to a modelled warning. The cumulative cap from #1393 refusing a second reversal is the cap working, no money moves, and the durable SystemEvent row is unchanged. Closes #1462 Closes #1463 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payments): a hold this request could not resume keeps blocking, and consent gates read the legacy purpose codes (#1465-triage, #1472) Review triage on #1465 found one real hole in the #1463 self-hold exclusion. `findSelfHoldAppointmentIds` excluded a buyer's own live hold on buyer, plan, status, deletion and window alone, but `findReusablePendingOrderPayment` will only resume or supersede a candidate whose `paymentGateway` and `organizationId` also match this request. A hold minted on another gateway, or under another org scope, was therefore taken off the calendar by a request that could neither adopt nor expire it: the same buyer minted a SECOND tentative appointment and a second payable gateway order over the same window, and both orders could capture. The exclusion now carries the resume gate's own two terms, so an unresumable hold keeps blocking and the buyer waits out its `expiresAt` instead of double-paying. The server-resolved org scope is threaded from `handleCheckout` through `calculateAmountAndValidate`, `revalidateInsideLock` and `createConsultationBooking`, defaulting to null (personal) so a caller that cannot resolve it fails closed. Step 2's duplicate-attempt guard deliberately keeps the unscoped liveness filter: it asks whether the buyer holds this window at all, and scoping it would let a second attempt on another gateway slip past the guard entirely. The plan-scope ternary chain became a switch, which is also the sonar S3358 finding on this PR's new code. Folding in #1472: `checkConsent`, `checkConsentBatch` and `withdrawConsent` matched `purposeCodes` against the canonical code exactly, so an artifact written under the pre-taxonomy kebab-case code (`session-booking`) was invisible to the fail-closed booking gate and every booking against that consultant answered 403 although `withdrawnAt` was null. A consent record is a legal artifact, so the gate has to recognise every code the platform ever wrote: `purposeCodeAliases` resolves a canonical code to itself plus each legacy alias that normalises to it, and the three lookups query `hasSome` over that set. Writes still normalise to the canonical form and the DB is not backfilled (pre-MVP reset). `checkConsent` keeps its `db` parameter — under PG_POOL_MAX=1 it must read through the caller's transaction (#1435). The other two review comments are answered without a code change. Coupling the credit-note refusal event to the refund transaction is refused because `recordSystemEvent` is a deliberately non-cascading global-client sink and the operator signal already survives on the Sentry warning. Cancelling a superseded gateway order is refused because `cancelRazorpayOrder` cannot make a Razorpay order unpayable — it only fetches the order's payments and logs — so the call would add in-lock gateway round-trips and remove nothing; a late capture is already the modelled CAPTURE_AFTER_TERMINAL_PAYMENT path. Closes #1472 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…t the reconciler loads (#1469) `@react-pdf/renderer` is in Next's built-in server-externals list, so the deployed function loads it — and `@react-pdf/reconciler` with it — through Node. That reconciler picks one of three bundled reconcilers by reading `React.version`, lands on this project's userland React 18.3.1, and reconciler-23 accepts only elements stamped `Symbol.for("react.element")`. Route-handler code is compiled in the `rsc` layer against Next's vendored React 19.2, which stamps `Symbol.for("react.transitional.element")`. Every element therefore reached the reconciler as an unrecognised object and all four statutory PDF routes answered 500 with React error #31. A `@jsxImportSource` pragma routes element creation in lib/pdf back through the runtime Node resolves, so both sides of the external boundary share one React. The tracer cannot see that require, so `react` is named explicitly for the four PDF routes. Closes #1468 Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ng, and org earnings are released from hold (#1473) * fix(payouts): the org payout journal clears the payable pre-withholding, and org earnings are released from hold (#1470, #1471) The ORG_PAYOUT posting read `netPayoutPaise` as if it were post-TDS. It is the pre-withholding host-org share; `amountPaise` is what the rail actually transfers. The old legs (Dr ORG_PAYABLE net+tds, Cr CASH net) balanced, so the write-time check and the nightly imbalance finding both accepted them while clearing the payable and crediting cash by exactly the withholding too much on every host-org payout, and `markOrgPayoutReversed` mirrored the same wrong shape so only a payout that stayed COMPLETED carried the overstatement. Completion and reversal now post the corrected legs under one shared assertion that `amountPaise + tdsAmountPaise === netPayoutPaise`, and they refuse to journal a guess when it fails: a SystemEvent plus a Sentry report raised from outside the transaction (a global-client write while a $transaction holds the only pooled connection deadlocks under PG_POOL_MAX=1), then a throw so the CAS rolls back for the at-least-once webhook and the stuck-payout sweep to re-drive. The #1354 TDS return input stops adding `tdsAmountPaise` on top of a figure that already includes it. The two schema columns now say which is pre- and post-withholding. Every scheduled release-earnings entry point imports the script, and the script touched only `consultantEarnings`, so `OrganizationEarnings` rows never left PENDING and a hosting organisation's retained share could never reach a payout batch (`createOrgPayoutBatch` selects READY only). The script now releases both tables, each in its own Serializable transaction with `status: PENDING` restated on the claim and its own copy of the ticker limit, oldest hold first, and the two counts are reported separately through the cleanup twin, the GitHub Actions outputs and the admin system-jobs runner. The dead dual implementation in earnings-service, which nothing called, is deleted with its barrel export. The reconciler's ORG_PAYOUT_TOTAL_MISMATCH check is scoped to the statuses where the earnings attachment is expected to hold. FAILED, REVERSED and CANCELLED payouts release their earnings back to READY with `orgPayoutId` cleared by design, so every one of them was being reported as drift. Closes #1470 Closes #1471 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(payouts): the org withholding assertion refuses negative figures, not just a broken identity (#1470) The equation `amountPaise + tdsAmountPaise === netPayoutPaise` is satisfied by a negative row as readily as by a correct one, and the posting site journals nothing unless `netPayoutPaise > 0` — so a payout carrying negative figures would have settled COMPLETED with its earnings marked PAID and no ORG_PAYOUT entry at all. `createOrgPayoutBatch` rejects a non-positive batch and nothing else mints an OrganizationPayout, so this closes a hand-edited-row hole rather than a live path, but it is the same class of unpostable money the identity guard exists for and it costs one comparison. Zero stays legal because a zero payout has nothing to post and is skipped deliberately. Also documents the refusal in the ledger-postings reference and gives the ORG_PAYOUT block the blank line markdownlint asked for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…pt, not a 500 (#1479) * fix(payments): an overdrawn org wallet answers 402 with a top-up prompt, not a 500 `WalletInsufficientFundsError` carried no machine-readable code, so checkout's catch did not recognise it, rewrote it to "Failed to record payment information. Please try again.", and reported it to Sentry as an unexpected fault. An organisation that had simply spent its wallet down could not tell the refusal from a crash, retried a booking that can never succeed, and paged us every time. The error now carries `code = "WALLET_INSUFFICIENT_FUNDS"` and `httpStatus = 402`, which is registered in `BUSINESS_ERROR_CODES` alongside the #1467 codes. That is enough for the existing `isBusinessErrorCode` rethrow to let it through the checkout transaction's catch intact, and the toast map now names the one action that clears it. Checkout tags it `expected: true` at both report sites: the inner one had a hand-maintained code list, and the outer one re-reported every code-carrying refusal as a fault immediately after the inner one had excused it. The route gets its own branch so the buyer sees actionable copy rather than a message naming the billing account and a paise figure, and so the refusal skips the unconditional `captureException` below it. Only the requested amount is reported: the overdraft guard is a conditional `updateMany` that refuses without reading the row, and re-reading the balance would add a query inside the checkout transaction that PG_POOL_MAX=1 serialises. Closes #1477 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): the route reports a business-coded refusal as an outcome, not an exception The generic tail of `POST /api/checkout` called `Sentry.captureException` before it had classified anything, so the only refusals that escaped an error-level event were the ones with an explicit `instanceof` branch above that line. Every code-carrying refusal that relies on the classifier instead — PROGRAM_CAP_EXHAUSTED and PROGRAM_SESSION_CAP_REACHED (#1458), the OVERAGE_* codes, PROGRAM_ASSIGNMENT_INACTIVE and BILLING_SUSPENDED_DUNNING (#1467) — answered the buyer with the right status and toast and still opened an incident each time an organisation's contract had merely lapsed. The tail now asks `isBusinessErrorCode` the same question the classifier is about to answer: a coded refusal goes through `reportSentryError` with `expected: true`, which lands at info, and anything else keeps the capture it had. The explicit branches above are untouched. Refs #1477 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 * fix(checkout): the wallet-overdraft branch reports its refusal like every other business outcome Review triage on #1479: the explicit WalletInsufficientFundsError branch returned before the route's expected-outcome report, so it was the one business-coded refusal with no Sentry breadcrumb. It now reports at info with expected: true, matching the generic path below it. Part of #1477 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
@coderabbitai ignore |
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Note Reviews pausedUse the following commands to manage reviews:
Use the checkboxes below for quick actions:
Comment |
✅ Action performedReviews paused. |
|




What ships
Finance/Money
Booking
Ops/CI
Docs
Database
The shared Supabase project already carries the union schema for this release. The sidecars (45 constraints / 6 indexes / 3 triggers, verified 2026-09-05) and the fixed leg trigger are already live there, so no push is needed for this release.
Owner actions still open
CRON_SECRETset so the ticker can run.PLATFORM_GSTINandSUPPLIER_STATE_CODEset; consumer invoices mint only once both are present.RESEND_API_KEYneeds rotating (P0: production email delivery dead since 2026-06-18 — invalid RESEND_API_KEY hard-locks signup and password reset #1298); the key hit its first production failure on 2026-09-05 at 11:13Z.This release restores production checkout, which has been dead since #1255 and is fixed here by #1435.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7